Вы работаете в стартапе, который продаёт продукты питания. Нужно разобраться, как ведут себя пользователи вашего мобильного приложения.
Изучите воронку продаж. Узнайте, как пользователи доходят до покупки. Сколько пользователей доходит до покупки, а сколько — «застревает» на предыдущих шагах? На каких именно?
После этого исследуйте результаты A/A/B-эксперимента. Дизайнеры захотели поменять шрифты во всём приложении, а менеджеры испугались, что пользователям будет непривычно. Договорились принять решение по результатам A/A/B-теста. Пользователей разбили на 3 группы: 2 контрольные со старыми шрифтами и одну экспериментальную — с новыми. Выясните, какой шрифт лучше. Создание двух групп A вместо одной имеет определённые преимущества. Если две контрольные группы окажутся равны, вы можете быть уверены в точности проведенного тестирования. Если же между значениями A и A будут существенные различия, это поможет обнаружить факторы, которые привели к искажению результатов. Сравнение контрольных групп также помогает понять, сколько времени и данных потребуется для дальнейших тестов.
В случае общей аналитики и A/A/B-эксперимента работайте с одними и теми же данными. В реальных проектах всегда идут эксперименты. Аналитики исследуют качество работы приложения по общим данным, не учитывая принадлежность пользователей к экспериментам.
Каждая запись в логе — это действие пользователя, или событие.
1. Открываем файл с данными и изучаем общую информацию
2. Предобработка данных
2.1 Сколько событий в логе: всего, уникальных, на пользователя?
2.2 За какой период у нас данные? выберем актуальный период, в котором данные полные
2.3 Оставим данные только за актуальный период и определим какова потеря данных.
2.4 Распределение событий и пользователей по группам
3. Изучим воронку событий
3.1. Распределение событий в логе, данные для построения воронки продаж(событий)
3.2. Построим столбчатую диаграмму распределение событий между этапами и воронку продаж (событий)
4. Изучим результаты эксперимента
4.1. Распределение пользователей по группам и по событиям
4.2. Функция для проверки наличия статистической разницы долей пользователей, совершивших событие
4.3. Проверка наличия статистической разницы долей пользователей, совершивших событие (А/А и А/В тесты)
4.3.1 Гипотеза: доли уникальных посетителей групп 246 и 247 (А/А тест), побывавших на этапе воронки, одинаковы
4.3.2 Гипотеза: доли уникальных посетителей групп 246 и 248 (А/В тест), побывавших на этапе воронки, одинаковы
4.3.3 Гипотеза: доли уникальных посетителей групп 247 и 248 (А/В тест), побывавших на этапе воронки, одинаковы
4.3.4 Гипотеза: доли уникальных посетителей групп 246+247 и 248 (А/В тест), побывавших на этапе воронки, одинаковы
5. Общий вывод
Откроем файл с данными и изучим общую информацию.
Подготовьте данные: Замените названия столбцов на удобные для вас; Проверьте пропуски и типы данных. Откорректируйте, если нужно; Добавьте столбец даты и времени, а также отдельный столбец дат;
# импортируем все необходимые библиотеки
import pandas as pd
import datetime as dt
import numpy as np
import matplotlib.pyplot as plt
from pandas.plotting import register_matplotlib_converters
import seaborn as sns
import warnings
warnings.filterwarnings('ignore')
import plotly
import plotly.graph_objs as go
import plotly.express as px
from plotly.subplots import make_subplots
import math as mth
from scipy import stats as st
#загружаем даннык из файла logs_exp.csv о A/A/B тестах
pd.set_option('display.max_colwidth', 0)
df = pd.read_csv('/datasets/logs_exp.csv', sep='\t')
#выводим таблицу
df.head(10)
| EventName | DeviceIDHash | EventTimestamp | ExpId | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 1564029816 | 246 |
| 1 | MainScreenAppear | 7416695313311560658 | 1564053102 | 246 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 1564054127 | 248 |
| 3 | CartScreenAppear | 3518123091307005509 | 1564054127 | 248 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 1564055322 | 248 |
| 5 | CartScreenAppear | 6217807653094995999 | 1564055323 | 248 |
| 6 | OffersScreenAppear | 8351860793733343758 | 1564066242 | 246 |
| 7 | MainScreenAppear | 5682100281902512875 | 1564085677 | 246 |
| 8 | MainScreenAppear | 1850981295691852772 | 1564086702 | 247 |
| 9 | MainScreenAppear | 5407636962369102641 | 1564112112 | 246 |
#переименовываем колонки для удобства
df.columns = ['event', 'userid', 'datetime', 'group']
# посмотрим информацию о таблице
display(df.info())
<class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event 244126 non-null object 1 userid 244126 non-null int64 2 datetime 244126 non-null int64 3 group 244126 non-null int64 dtypes: int64(3), object(1) memory usage: 7.5+ MB
None
# сколько пропусков в данных
print('сколько пропусков в данных?')
display(df.isna().sum())
сколько пропусков в данных?
event 0 userid 0 datetime 0 group 0 dtype: int64
# сколько дубликатов?
print('сколько дубликатов:', df.duplicated().sum())
сколько дубликатов: 413
# удаляем 413 дубликатов
df = df.drop_duplicates().reset_index(drop = True)
# приводим даты к нужному формату
df['datetime'] = pd.to_datetime(df['datetime'], unit='s')
# добавляем столбец с датами
df['eventdate'] = pd.to_datetime(df['datetime']).dt.date
#проверяем форматы
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 243713 entries, 0 to 243712 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event 243713 non-null object 1 userid 243713 non-null int64 2 datetime 243713 non-null datetime64[ns] 3 group 243713 non-null int64 4 eventdate 243713 non-null object dtypes: datetime64[ns](1), int64(2), object(2) memory usage: 9.3+ MB
# проверяем правильность замены форматов, выводим верх таблицы
df.head()
| event | userid | datetime | group | eventdate | |
|---|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 2019-07-25 04:43:36 | 246 | 2019-07-25 |
| 1 | MainScreenAppear | 7416695313311560658 | 2019-07-25 11:11:42 | 246 | 2019-07-25 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 2019-07-25 11:28:47 | 248 | 2019-07-25 |
| 3 | CartScreenAppear | 3518123091307005509 | 2019-07-25 11:28:47 | 248 | 2019-07-25 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 2019-07-25 11:48:42 | 248 | 2019-07-25 |
# Дополнительно проверим, есть ли пересечения пользователей в группах?
print('количество групп в которых есть пересечение пользователей равно:',
(
df
.groupby('userid')
.agg({'group' : 'nunique'})
.query('group>1')
.count()
))
количество групп в которых есть пересечение пользователей равно: group 0 dtype: int64
Изначально в таблице было 244126 строк(событий) и 4 столбца:
- EventName(event - в скобках новое название) - имя события (object)
- DeviceIDHash(userid) - ID пользователя (int64)
- EventTimestamp(datetime) - дата и время события (int64)
- ExpId(group) - название группы (int64)
Переименовываем столбцы
Столбец дата и время события имеет числовой формат, меняем его на datetime64.
Проверяем пропуски в данных - их нет, полных дубликатов 413. Удаляем дубликаты.
Добавляем столбец даты события "eventdate", меняем формат столбца на date.
Также мы проверили - не попалили данные из одной группы в другую - нет, пересечения данных в группах нет
Проверка и подготовка данных завершена
Изучим следующие вопросы:
Сколько всего событий в логе?
Сколько всего пользователей в логе?
Сколько в среднем событий приходится на пользователя?
Данными за какой период вы располагаете? Найдем максимальную и минимальную дату. Построим гистограмму по дате и времени. Можно ли быть уверенным, что у вас одинаково полные данные за весь период? Технически в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого — это может «перекашивать данные». Определим, с какого момента данные полные и отбросим более старые. Данными за какой период времени располагаем на самом деле?
Много ли событий и пользователей мы потеряли, отбросив старые данные?
Проверим, что у нас есть пользователи из всех трёх экспериментальных групп.
# Сколько всего событий в логе?
events_total = df['event'].count()
print('Всего событий в логе:', events_total)
Всего событий в логе: 243713
# Сколько всего уникальных событий в логе?
print('список уникальных событий:', df['event'].unique())
print('всего уникальных событий:', len(df['event'].unique()))
список уникальных событий: ['MainScreenAppear' 'PaymentScreenSuccessful' 'CartScreenAppear' 'OffersScreenAppear' 'Tutorial'] всего уникальных событий: 5
# Сколько всего уникальных пользователей в логе?
users_uniq = len(df['userid'].unique())
print('всего уникальных пользователей в логе', users_uniq)
всего уникальных пользователей в логе 7551
# посмотрим описание таблицы чтобы определить наличие выбросов
events_per_users = df.groupby('userid')['event'].agg('count')
# Сколько в среднем событий приходится на пользователя?
print('среднее количество событий на пользователя:', events_per_users.mean().astype(int))
print('медианное количество событий на пользователя:',events_per_users.median().astype(int))
среднее количество событий на пользователя: 32 медианное количество событий на пользователя: 20
# посмотрим описание таблицы
events_per_users.describe()
count 7551.000000 mean 32.275593 std 65.154219 min 1.000000 25% 9.000000 50% 20.000000 75% 37.000000 max 2307.000000 Name: event, dtype: float64
2307 событий у одного пользователя и медиана 20 - это слишком большой разброс, явно есть выбросы. Построим гистограмму "среднее количество событий на пользователя"
# Построим гистограмму
plt.figure(figsize=(15,5))
plt.hist(events_per_users, bins=100, range=(150,2307))
plt.xlabel('события')
plt.ylabel('пользователи')
plt.grid()
plt.title('среднее количество событий на пользователя')
plt.show()
Действительно, гистограмма имеет вид - распределение Пуасона начиная с 650 - это явно выбросы.
# Данными за какой период вы располагаете?
# Найдите максимальную и минимальную дату.
print('минимальная дата лога', df['datetime'].min())
print('максимальная дата лога', df['datetime'].max())
print('в логе присутствуют данные с ', df['datetime'].min(), ' по ', df['datetime'].max())
минимальная дата лога 2019-07-25 04:43:36 максимальная дата лога 2019-08-07 21:15:17 в логе присутствуют данные с 2019-07-25 04:43:36 по 2019-08-07 21:15:17
# Постройте гистограмму по дате и времени.
plt.figure(figsize=(16, 5))
plt.hist(df['datetime'], color = 'yellow', edgecolor = 'black', bins=14*24, range=('2019-07-27 18:00:00','2019-08-07 06:00:00'))
plt.xlabel('дата и время событий')
plt.grid()
plt.ylabel('Количествово событий')
plt.title('гистограмма событий по дате и времени')
plt.xticks(color='black', rotation=45, fontweight='bold', fontsize='9', horizontalalignment='right')
plt.show()
На гистограмме явно видно что мобильным приложением стали активно пользоваться в промежуток времени между 2019-07-31 и 2019-08-01. Поэтому приблизим гистограмму выбрав именно этот временной промежуток.
Также на графике заметно что в июле событий практически нет, поэтому мы можем отсечь события до момента, который нам покажет укрупненная гистограмма. Построим ее.
plt.figure(figsize=(16, 5))
plt.hist(df['datetime'], color = 'yellow', edgecolor = 'black', bins=14*24, range=('2019-07-31 20:00:00','2019-07-31 22:00:00'))
plt.xlabel('дата и время событий')
plt.grid()
plt.ylabel('Количествово событий')
plt.title('гистограмма событий по дате и времени (укрупненно)')
plt.xticks(color='black', rotation=45, fontweight='bold', fontsize='9', horizontalalignment='right')
plt.show()
по графикам видно что начало активного этапа - 2019-07-31 21:10:00. Это и примем за точку отсечения данных
# применим логическую индексацию и оставим только нужные время и даты
df_filtered = df.loc[df['datetime']>'2019-07-31 21:10:00'].reset_index(drop=True)
df.sort_values(by='datetime')
df.head(10)
| event | userid | datetime | group | eventdate | |
|---|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 2019-07-25 04:43:36 | 246 | 2019-07-25 |
| 1 | MainScreenAppear | 7416695313311560658 | 2019-07-25 11:11:42 | 246 | 2019-07-25 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 2019-07-25 11:28:47 | 248 | 2019-07-25 |
| 3 | CartScreenAppear | 3518123091307005509 | 2019-07-25 11:28:47 | 248 | 2019-07-25 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 2019-07-25 11:48:42 | 248 | 2019-07-25 |
| 5 | CartScreenAppear | 6217807653094995999 | 2019-07-25 11:48:43 | 248 | 2019-07-25 |
| 6 | OffersScreenAppear | 8351860793733343758 | 2019-07-25 14:50:42 | 246 | 2019-07-25 |
| 7 | MainScreenAppear | 5682100281902512875 | 2019-07-25 20:14:37 | 246 | 2019-07-25 |
| 8 | MainScreenAppear | 1850981295691852772 | 2019-07-25 20:31:42 | 247 | 2019-07-25 |
| 9 | MainScreenAppear | 5407636962369102641 | 2019-07-26 03:35:12 | 246 | 2019-07-26 |
У нас есть пользователи из всех трёх экспериментальных групп - это видно из выведенной выше таблицы, см. выше.
Много ли событий и пользователей вы потеряли, отбросив старые данные?
new_users_uniq = df_filtered['userid'].nunique()
print(f'пользователей до отсечения - {users_uniq} после отсечения - {new_users_uniq}уменьшилось на - {users_uniq-new_users_uniq}')
print(f'Потеря пользователей - {round((users_uniq-new_users_uniq)/users_uniq*100,2)}%')
new_events = df_filtered['event'].count()
print(f'событий до отсечения - {events_total} после отсечения - {new_events} уменьшилось на - {events_total-new_events}')
print(f'Потеря событий - {round((events_total-new_events)/events_total*100,2)}%')
пользователей до отсечения - 7551 после отсечения - 7538уменьшилось на - 13 Потеря пользователей - 0.17% событий до отсечения - 243713 после отсечения - 241701 уменьшилось на - 2012 Потеря событий - 0.83%
Потери событий и пользователей менее 1% - не критичны для данных.
Проверим, что у нас есть пользователи из всех трёх экспериментальных групп.
# посчитаем количество событий по группам
display('количество событий по группам', df['group'].value_counts())
# посчитаем количество уникальных пользователей по группам
display('количество уникальных пользователей по группам', df.groupby('group')['userid'].nunique())
'количество событий по группам'
248 85582 246 80181 247 77950 Name: group, dtype: int64
'количество уникальных пользователей по группам'
group 246 2489 247 2520 248 2542 Name: userid, dtype: int64
Всего событий в логе: 243713
список уникальных событий:
среднее количество событий на пользователя: 32 медианное количество событий на пользователя: 20
Для выбора мреднего или медианного значения посмотрели описание таблицы - 2307 событий у одного пользователя(максимум) и медиана 20 - это слишком большой разброс, явно есть выбросы. Построим гистограмму "среднее количество событий на пользователя"
Действительно, гистограмма имеет вид - распределение Пуасона начиная с 650 - это явно выбросы. Поэтому принимаем медианное значение за среднее на пользователя.
В логе присутствуют данные с 2019-07-25 04:43:36 по 2019-08-07 21:15:17
Перестроив гистограмму укрупненно мы определили что начало активного этапа - 2019-07-31 21:10:00. Это и приняли за точку отсечения данных
Потери событий и пользователей после этого отсечения составляют менее 1% - не критичны для данных. Также мы проверили что есть пользователи из всех трёх экспериментальных групп - вывели верх таблицы на экран.
'количество событий по группам' 248 84868 246 79545 247 77288 Name: group, dtype: int64 'количество уникальных пользователей по группам' group 246 2484 247 2517 248 2537 Name: userid, dtype: int64
Несмотря на то что разница в событиях между группами достигает 7.5 тысяч(около 9%), уникальных пользователей в каждой группе одинаково разница около 2% - это меньше статичтической погрешности. Будем считать что данные по группам распределены равномерно.
Рассмотрим следующие вопросы:
# какие события есть в логе?
print(df['event'].unique())
['MainScreenAppear' 'PaymentScreenSuccessful' 'CartScreenAppear' 'OffersScreenAppear' 'Tutorial']
# Как часто уникальные события встречаются в логах. Отсортируем события по частоте
events_dispers = df_filtered['event'].value_counts()
print('Распределение событий в логе')
display(events_dispers)
Распределение событий в логе
MainScreenAppear 117876 OffersScreenAppear 46523 CartScreenAppear 42342 PaymentScreenSuccessful 33950 Tutorial 1010 Name: event, dtype: int64
# создаем таблицу для построения воронки продаж
event_group = (df_filtered.groupby('event')
.agg({'group' : 'count', 'userid' : 'nunique'})
.reset_index()
.sort_values(by='group', ascending=False))
event_group.columns = ['event', 'events_count', 'users_count']# переименовываем колонки
# добавляем столбец процент пользователей попавших на данный шаг
event_group['persent_of_users'] = round(100*event_group['users_count'] / df_filtered['userid'].nunique(),1)
# добавляем столбец конверсия шага
event_group['conversation_rate'] = round(100*event_group['users_count'] / users_uniq,1)
# добавляем столбец конверсия с предыдущего шага
event_group['c_rate_next_step'] = round(100*event_group['users_count'] / event_group['users_count'].shift(),1)
print('')
print('данные для построения воронки продаж')
event_group = event_group.fillna(100)
event_group
данные для построения воронки продаж
| event | events_count | users_count | persent_of_users | conversation_rate | c_rate_next_step | |
|---|---|---|---|---|---|---|
| 1 | MainScreenAppear | 117876 | 7423 | 98.5 | 98.3 | 100.0 |
| 2 | OffersScreenAppear | 46523 | 4597 | 61.0 | 60.9 | 61.9 |
| 0 | CartScreenAppear | 42342 | 3736 | 49.6 | 49.5 | 81.3 |
| 3 | PaymentScreenSuccessful | 33950 | 3540 | 47.0 | 46.9 | 94.8 |
| 4 | Tutorial | 1010 | 843 | 11.2 | 11.2 | 23.8 |
В таблиу для построения воронки продаж мы добавили столбцы:
# Построим столбчатую диаграмму "Количество событий по числу пользователей" при переходе с этапа на этап
plt.figure(figsize=(15,7))
sns.barplot(y='users_count', x='event', data=event_group)
plt.xlabel('Событие(event)')
plt.grid()
plt.ylabel('Количество пользователей(users)')
plt.title('Диаграмма распределение \n Событий по числу пользователей между этапами')
plt.xticks(color='black', rotation=30, fontweight='bold', fontsize='9', horizontalalignment='right')
plt.show();
# Построим воронку продаж, исключив событие Tutorial
print('')
print(' Воронка продаж')
fig = go.Figure(
go.Funnel(
y=event_group['event'].head(4),
x=event_group['users_count'],
textinfo = 'value+percent initial+percent previous'
)
)
fig.show()
Воронка продаж
В логе 5 событий:
MainScreenAppear - Главный экран - 117876
OffersScreenAppear - Экран каталога товаров - 42342
CartScreenAppear - Экран просмотра корзины - 42342
PaymentScreenSuccessful - Экран успешной оплаты - 33950
Tutorial - Экран руководство пользователя - 1010
События могут происходить следующим образом:
Количество пользователей, которые совершили событие хотя бы раз:
Доля пользователей совершивших переход на следующий шаг воронки:
Поскольку целевое событие - это PaymentScreenSuccessful - экран успешной оплаты, а максимальное количество пользователей приходится на событие MainScreenAppear - Главный экран, логично предположить что воронка строится между этими двумя событиями.
Ни одно из событий не является обязательным и также какое-то событие пользователь может пропустить. Очевидно, что экран Tutorial - руководство пользователя пропускают чаще всего.
При выводе таблицы можно видеть что время экрана корзины и успешной оплаты бывает раньше чем просмотр экрана каталога и посещения главного экрана. Могу предположить что это случай, не первого визита пользователя в приложение. Скорее всего после просмотра отложенной корзины и ее оплаты - пользователь еще захотел что-либо выбрать. Кроме того среднее количество событий на одного пользователя в неделю(отсеченный нами период - первая неделя августа и данные нашего лога - медианное значение 20 событий) - это в среднем 3 события в день. Таким образом, при условии использования приложения ежедневно - минимум одно событие в среднем должно быть пропущено. Если рассуждать еще дальше - то скорее всего в каталог и корзину пользователь заходит при крупных покупках, в случае ежедневных мелких покупок - оплата может происходить без просмотра каталога - пользователь покупает новинки и спецпредложения которые видит на главном экране.
Больше всего потеря почти 40% происходит на 2-м шаге - открытие экрана каталога.
Почти 47% пользователей совершают покупку.
Расхождение в цифрах на воронке и в выводе связано с тем что в расчетах мы берем отфильтрованные данные по дате отсечения, а воронку строим по общему количеству зашедших на первый этап "Главный экран".
Ответим на вопросы:
# Создаем таблицу экспериментальных данных - количество уникальных пользователей по группам
users_by_group = (
df_filtered.groupby('group')
.agg({'userid':'nunique'})
.sort_values('group')
.reset_index()
)
display('Количество уникальных пользователей по группам users_by_group', users_by_group)
'Количество уникальных пользователей по группам users_by_group'
| group | userid | |
|---|---|---|
| 0 | 246 | 2484 |
| 1 | 247 | 2517 |
| 2 | 248 | 2537 |
# Создаем таблицу данных для проведения А/А и А/В тестирования
user_by_group_event = (
df_filtered.pivot_table(index='event', columns='group', values='userid', aggfunc='nunique')
.sort_values(246, ascending=False))
# добавляем новые столбцы
user_by_group_event['246+247'] = user_by_group_event[246] + user_by_group_event[247]# Сумма групп АА(246+247)
#доля событий к первому этапу 246 группа
user_by_group_event['percent_246'] = 100 * user_by_group_event[246]/user_by_group_event[246][0]
#доля событий к первому этапу 247 группа
user_by_group_event['percent_247'] = 100 * user_by_group_event[247]/user_by_group_event[247][0]
#доля событий к первому этапу 248 группа
user_by_group_event['percent_248'] = 100 * user_by_group_event[248]/user_by_group_event[248][0]
# переименовываем колонки
user_by_group_event.columns = ['246','247','248','246+247', 'percent_246', 'percent_247', 'percent_248']
print('Данные для А/В и АА тестов user_by_group_event')
display(user_by_group_event)
Данные для А/В и АА тестов user_by_group_event
| 246 | 247 | 248 | 246+247 | percent_246 | percent_247 | percent_248 | |
|---|---|---|---|---|---|---|---|
| event | |||||||
| MainScreenAppear | 2450 | 2479 | 2494 | 4929 | 100.000000 | 100.000000 | 100.000000 |
| OffersScreenAppear | 1542 | 1524 | 1531 | 3066 | 62.938776 | 61.476402 | 61.387330 |
| CartScreenAppear | 1266 | 1239 | 1231 | 2505 | 51.673469 | 49.979831 | 49.358460 |
| PaymentScreenSuccessful | 1200 | 1158 | 1182 | 2358 | 48.979592 | 46.712384 | 47.393745 |
| Tutorial | 278 | 284 | 281 | 562 | 11.346939 | 11.456232 | 11.267041 |
Итак мы видим, что по группам уникальные пользователи распределены так:
Опеределим статистическую достоверность в контрольных группах по долям пользователей, совершивших событие.
напишем функцию, которая принимает в качестве аргументов 4 числа для z-теста пропорций (в теле функции не должно быть ни цикла, ни работы с датафреймом, только мат. вычисления для стат. теста.
Для корректности рассчетов применим поправку Бонферрони - разделим уровень статистической значимости на количество экспериментов(16 - 4 события на 4 теста А/А(246/247), А/В 246/248, 247/348 и 246+247/248).
Поскольку 5%/16=менее 1% - значит уровень статистической значимости для А/А эксперимента понижать не будем - он и так низкий.
# функция для проверки статистической разницы между долями пользователей посетивших один из этапов
def z_test(sucsess1, sucsess2, trials1, trials2):
alpha = 0.05/16 # критический уровень статистической значимости для корректности применим поправку Бонферрони и
#поделим уровень значимости на количество экспериментов. Она представляет собой безопасный способ смягчить
#увеличение вероятности совершения ошибки 1-го рода при многократной проверке.
# пропорция успехов в первой группе:
p1 = sucsess1/trials1
# пропорция успехов во второй группе:
p2 = sucsess2/trials2
# пропорция успехов в комбинированном датасете:
p_combined = (sucsess1 + sucsess2) / (trials1 + trials2)
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials1 + 1/trials2))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными'
)
return p1, p2, p_value
Примем
Нулевая гипотеза: доли уникальных посетителей групп 246 и 247 (А/А тест), побывавших на этапе воронки, одинаковы.
Альтенативная гипотеза: между долями уникальных посетителей, побывавших на этапе воронки, есть значимая разница.
Проверим ее для каждого из событий.
# создадим список из уникальных событий
events_list = ['MainScreenAppear', 'OffersScreenAppear', 'CartScreenAppear', 'PaymentScreenSuccessful']
# количество пользователей по группам (главный экран)
trials1 = users_by_group.loc[users_by_group['group'] == 246, 'userid'].values[0]
trials2 = users_by_group.loc[users_by_group['group'] == 247, 'userid'].values[0]
# в цикле проверим статистическую разницу между группами по каждому из этапов
for event in events_list:
print('')
print('для события', event)
sucsess1 = user_by_group_event['246'][event]
sucsess2 = user_by_group_event['247'][event]
# вызовем функцию
p1, p2, p_value = z_test(sucsess1, sucsess2, trials1, trials2)
для события MainScreenAppear p-значение: 0.6756217702005545 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными для события OffersScreenAppear p-значение: 0.26698769175859516 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными для события CartScreenAppear p-значение: 0.2182812140633792 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными для события PaymentScreenSuccessful p-значение: 0.10298394982948822 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Примем
Нулевая гипотеза: доли уникальных посетителей групп 246 и 248 экспериментальной (А/В тест), побывавших на этапе воронки, одинаковы.
Альтенативная гипотеза: между долями уникальных посетителей, побывавших на этапе воронки, есть значимая разница.**
Проверим ее для каждого из событий.
# количество пользователей по группам (главный экран)
trials1 = users_by_group.loc[users_by_group['group'] == 246, 'userid'].values[0]
trials2 = users_by_group.loc[users_by_group['group'] == 248, 'userid'].values[0]
# в цикле проверим статистическую разницу между группами по каждому из этапов
for event in events_list:
print('')
print('для события', event)
sucsess1 = user_by_group_event['246'][event]
sucsess2 = user_by_group_event['248'][event]
# вызовем функцию
p1, p2, p_value = z_test(sucsess1, sucsess2, trials1, trials2)
для события MainScreenAppear p-значение: 0.34705881021236484 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными для события OffersScreenAppear p-значение: 0.20836205402738917 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными для события CartScreenAppear p-значение: 0.08328412977507749 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными для события PaymentScreenSuccessful p-значение: 0.22269358994682742 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Примем
Нулевая гипотеза: доли уникальных посетителей групп 247 и 248 экспериментальной (А/В тест), побывавших на этапе воронки, одинаковы.
Альтенативная гипотеза: между долями уникальных посетителей, побывавших на этапе воронки, есть значимая разница.**
Проверим ее для каждого из событий.
# количество пользователей по группам (главный экран)
trials1 = users_by_group.loc[users_by_group['group'] == 247, 'userid'].values[0]
trials2 = users_by_group.loc[users_by_group['group'] == 248, 'userid'].values[0]
# в цикле проверим статистическую разницу между группами по каждому из этапов
for event in events_list:
print('')
print('для события', event)
sucsess1 = user_by_group_event['247'][event]
sucsess2 = user_by_group_event['248'][event]
# вызовем функцию
p1, p2, p_value = z_test(sucsess1, sucsess2, trials1, trials2)
для события MainScreenAppear p-значение: 0.6001661582453706 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными для события OffersScreenAppear p-значение: 0.8835956656016957 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными для события CartScreenAppear p-значение: 0.6169517476996997 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными для события PaymentScreenSuccessful p-значение: 0.6775413642906454 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Примем
Нулевая гипотеза: доли уникальных посетителей групп 246+247 и 248 экспериментальной (А/В тест), побывавших на этапе воронки, одинаковы.
Альтенативная гипотеза: между долями уникальных посетителей, побывавших на этапе воронки, есть значимая разница.**
Проверим ее для каждого из событий.
# количество пользователей по группам (главный экран)
trials1 = users_by_group.loc[users_by_group['group'] == 246, 'userid'].values[0] +users_by_group.loc[users_by_group['group'] == 247, 'userid'].values[0]
trials2 = users_by_group.loc[users_by_group['group'] == 248, 'userid'].values[0]
# в цикле проверим статистическую разницу между группами по каждому из этапов
for event in events_list:
print('')
print('для события', event)
sucsess1 = user_by_group_event['246+247'][event]
sucsess2 = user_by_group_event['248'][event]
# вызовем функцию
p1, p2, p_value = z_test(sucsess1, sucsess2, trials1, trials2)
для события MainScreenAppear p-значение: 0.39298914928006035 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными для события OffersScreenAppear p-значение: 0.418998284007599 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными для события CartScreenAppear p-значение: 0.19819340844527744 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными для события PaymentScreenSuccessful p-значение: 0.6452057673098244 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Значимой разницы во всех тестах между группами не выявлено
Ни один из экспериментов не опроверг нулевую гипотезу о равности долей тестируемых групп включая экспериментальную 248.
Стало быть изменение шрифтов выбранное дизайнером не повлияет на работу приложения интернет магазина продуктов.
Поскольку максимальное снижение количества пользователей происходит на 2-м этапе(событие - Экран каталога) я бы на месте маркетолога этой компании обратила внимание на такие характеристики:
В ходе анализа и эксперементов было сделано и выявлено:
Изначально в таблице было 244126 строк(событий) и 4 столбца:
В процессе изучения данных выяснилось что всего событий в логе: 243713
список уникальных событий:
всего уникальных пользователей в логе 7551
Так как в данных есть выбросы, которые показала гистограмма - мы выбрали для изучения не среднее а медианное количество событий на пользователя: 20
В логе присутствуют данные с 2019-07-25 04:43:36 по 2019-08-07 21:15:17
Перестроив гистограмму укрупненно мы определили что начало активного этапа - 2019-07-31 21:10:00. Это и приняли за точку отсечения данных
Потери событий и пользователей после этого отсечения составляют менее 1% - не критичны для данных. Также мы проверили что есть пользователи из всех трёх экспериментальных групп - вывели верх таблицы на экран.
'количество уникальных пользователей по группам' group 246 2484 247 2517 248 2537
Снижение Уникальных пользователей в каждой группе одинаково разница около 2% - это меньше статичтической погрешности. Будем считать что данные по группам распределены равномерно.
В логе 5 событий:
MainScreenAppear - Главный экран - 117876
OffersScreenAppear - Экран каталога товаров - 42342
CartScreenAppear - Экран просмотра корзины - 42342
PaymentScreenSuccessful - Экран успешной оплаты - 33950
Tutorial - Экран руководство пользователя - 1010
События могут происходить следующим образом:
Количество пользователей, которые совершили событие хотя бы раз:
Доля пользователей совершивших переход на следующий шаг воронки:
Поскольку целевое событие - это PaymentScreenSuccessful - экран успешной оплаты, а максимальное количкство пользователей приходится на событие MainScreenAppear - Главный экран, логично предположить что воронка строится между этими двумя событиями.
Я согласна, что ни одно из событий не является обязательным и также какое-то событие пользователь может пропустить. Очевидно, что экран Tutorial - руководство пользователя пропускают чаще всего.
При выводе таблицы можно видеть что время экрана корзины и успешной оплаты бывает раньше чем просмотр экрана каталога и посещения главного экрана. Могу предположить что это случай, не первого визита пользователя в приложение. Скорее всего после просмотра отложенной корзины и ее оплаты - пользователь еще захотел что-либо выбрать. Кроме того среднее количество событий на одного пользователя в неделю(отсеченный нами период - первая неделя августа и данные нашего лога - медианное значение 20 событий) - это в среднем 3 события в день. Таким образом, при условии использования приложения ежедневно - минимум одно событие в среднем должно быть пропущено. Если рассуждать еще дальше - то скорее всего в каталог и корзину пользователь заходит при крупных покупках, в случае ежедневных мелких покупок - оплата может происходить без просмотра каталога - н покупает новинки и спецпредложения которые видит на главном экране.
Больше всего потеря почти 40% происходит на 2-м шаге - открытие экрана каталога.
Почти 47% пользователей совершают покупку.
Расхождение в цифрах на воронке и в выводе связано с тем что в расчетах мы берем отфильтрованные данные по дате отсечения, а воронку строим по общему количеству зашедших на первый этап Главный экран.
Итак мы видим, что по группам уникальные пользователи распределены так:
248 - 2537 - эта группа экспериментальная
Опеределим статистическую достоверность в контрольных группах по долям пользователей, совершивших событие.
Для этого мы написали функцию, которая принимает в качестве аргументов 4 числа для z-теста пропорций (в теле функции не должно быть ни цикла, ни работы с датафреймом, только мат. вычисления для стат. теста.)
Для корректности рассчетов применим поправку Бонферрони - разделим уровень статистической значимости на количество экспериментов(16 - 4 события на 4 теста А/А(246/247), А/В 246/248, 247/348 и 246+247/248).
Поскольку 5%/16=менее 1% - значит уровень статистической значимости для А/А эксперимента понижать не стали - он и так низкий.
Применив H0 гипотезу что статистические критерии не находят разницу между выборками в контрольных группах. Проверим ее для каждого из событий.
В ходе тестирования были проведены 16 экспериментов:
246/247 - 4 эксперемента (для каждого события) / уровень статистической значимости - 5%/16
246/248 - 4 эксперемента (для каждого события) / уровень статистической значимости - 5%/16
247/248 - 4 эксперемента (для каждого события) / уровень статистической значимости - 5%/16
246+247/248 - 4 эксперемента (для каждого события) / уровень статистической значимости - 5%/16
Уровень статистической значимости в 10% был бы слишком велик так как в тесте не ожидаем изменений не менее чем 30% (в таком случае 10% погрешности измерений нас бы устроила). При уровне значимости 0.1 только одна из проверок покажет значимую разницу, между контрольной группой A0 и экспериментальной в доле перехода пользователей в корзину(CartScreenAppear), но эта разница будет не в пользу нашей экспериментальной группы. Но при уровне значимости 0.1 каждый десятый раз можно получать ложный результат, поэтому стоит применить изначально выбранный нами уровень значимости 0.05.
В результате всех и каждого A/A/B эксперемента значимой разницы между группами не выявлено. Поэтому можно утверждать, что на поведение пользователей изменение шрифта значимого эффекта не оказало. Тестирование можно назвать успешным - изменение шрифта не повлияло на поведение пользователей.
Ни один из экспериментов не опроверг нулевую гипотезу о равности долей тестируемых групп включая экспериментальную 248. Стало быть изменение шрифтов выбранное дизайнером не повлияет на работу приложения интернет магазина продуктов. Поскольку максимальное снижение количества пользователей происходит на 2-м этапе(событие - Экран каталога) я бы на месте маркетолога этой компании обратила внимание на такие характеристики:
Кроме того есть рекомендации по дизайну главного экрана приложения - как гипотезы для проверки: